useUiStore
(UI 狀態)與 useProjectsStore
(資料/快取)npm i pinia
// src/main.ts
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import { router } from './router'
import './styles/base.css'
createApp(App)
.use(router)
.use(createPinia())
.mount('#app')
// src/stores/ui.ts
import { defineStore } from 'pinia'
type Theme = 'light' | 'dark'
type Cat = 'all' | 'frontend' | 'backend' | 'tools'
export const useUiStore = defineStore('ui', {
state: () => ({
theme: 'light' as Theme,
skillCategory: 'all' as Cat,
keyword: '' as string,
}),
getters: {
isDark: (s) => s.theme === 'dark',
},
actions: {
setTheme(theme: Theme) {
this.theme = theme
// 立即反映到 <html data-theme=...>
document.documentElement.setAttribute('data-theme', theme)
// 持久化(簡易版)
localStorage.setItem('ui.theme', theme)
},
toggleTheme() {
this.setTheme(this.isDark ? 'light' : 'dark')
},
setSkillCategory(cat: Cat) { this.skillCategory = cat; localStorage.setItem('ui.cat', cat) },
setKeyword(kw: string) { this.keyword = kw; localStorage.setItem('ui.kw', kw) },
// 初始化:將 localStorage 的值載回
initFromStorage() {
const t = localStorage.getItem('ui.theme') as Theme | null
const c = localStorage.getItem('ui.cat') as Cat | null
const k = localStorage.getItem('ui.kw')
if (t) this.setTheme(t)
if (c) this.skillCategory = c
if (k !== null) this.keyword = k
}
}
})
小提醒:我們直接在 action 裡同步更新 document.documentElement,確保切換主題立即生效。
// src/stores/projects.ts
import { defineStore } from 'pinia'
export type Project = {
id: number
slug: string
title: string
tech: string
desc: string
tags: string[]
images: string[]
demo: string
repo: string
featured: boolean
}
export const useProjectsStore = defineStore('projects', {
state: () => ({
items: [] as Project[],
loaded: false,
loading: false,
error: null as Error | null,
lastFetchedAt: null as number | null,
}),
getters: {
count: (s) => s.items.length,
bySlug: (s) => (slug: string) => s.items.find(p => p.slug === slug),
},
actions: {
async fetchAll(force = false) {
if (this.loaded && !force) return
this.loading = true
this.error = null
try {
// 你可以改成真的 API,例如:/api/projects
const res = await fetch('/projects.json', { cache: 'no-cache' })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json() as Project[]
this.items = data
this.loaded = true
this.lastFetchedAt = Date.now()
} catch (err: any) {
this.error = err
this.items = []
this.loaded = false
} finally {
this.loading = false
}
}
}
})
// src/App.vue
<script setup lang="ts">
import { RouterView } from 'vue-router'
import SiteHeader from '@/components/SiteHeader.vue'
import SiteFooter from '@/components/SiteFooter.vue'
import { onMounted } from 'vue'
import { useUiStore } from '@/stores/ui'
const ui = useUiStore()
onMounted(() => {
ui.initFromStorage() // 從 localStorage 讀回
// 初次掛載依 Theme 設置 data-theme(init 已做,不過再保險一次)
document.documentElement.setAttribute('data-theme', ui.theme)
})
</script>
<template>
<SiteHeader />
<RouterView />
<SiteFooter />
</template>
<!-- src/components/SiteHeader.vue -->
<template>
<header class="site-header">
<div class="container">
<a class="brand" href="#home" aria-label="回到頁面頂部">Chiayu</a>
<nav class="site-nav" aria-label="主選單">
<ul>
<li><a href="#about">關於我</a></li>
<li><a href="#skills">技能</a></li>
<li><a href="#projects">作品</a></li>
<li><a href="#contact">聯絡</a></li>
</ul>
</nav>
<button class="btn btn-outline small" type="button" @click="ui.toggleTheme()">
{{ ui.isDark ? '切換為亮色' : '切換為暗色' }}
</button>
</div>
</header>
</template>
<script setup lang="ts">
import { useUiStore } from '@/stores/ui'
const ui = useUiStore()
</script>
<!-- src/components/Skills.vue -->
<template>
<section id="skills" class="container section" aria-labelledby="skills-title">
<div class="section-header">
<h2 id="skills-title">技能 Skillset</h2>
<div role="tablist" aria-label="技能分類" class="filters">
<button class="chip" role="tab" :aria-selected="ui.skillCategory==='all'" @click="ui.setSkillCategory('all')">全部</button>
<button class="chip" role="tab" :aria-selected="ui.skillCategory==='frontend'" @click="ui.setSkillCategory('frontend')">前端</button>
<button class="chip" role="tab" :aria-selected="ui.skillCategory==='backend'" @click="ui.setSkillCategory('backend')">後端</button>
<button class="chip" role="tab" :aria-selected="ui.skillCategory==='tools'" @click="ui.setSkillCategory('tools')">工具</button>
</div>
</div>
<div class="field" style="margin:12px 0;">
<label for="skill-search">關鍵字搜尋</label>
<input id="skill-search" type="text" :value="ui.keyword" @input="onInput" placeholder="例如:Vue、Docker…" />
</div>
<ul class="skill-grid">
<li v-for="s in filtered" :key="s.name">{{ s.name }}</li>
</ul>
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useUiStore } from '@/stores/ui'
import { skills } from '@/data/skills'
const ui = useUiStore()
// 簡易 debounce
let tid: number | undefined
function onInput(e: Event) {
const v = (e.target as HTMLInputElement).value
window.clearTimeout(tid)
tid = window.setTimeout(() => ui.setKeyword(v.trim()), 300)
}
const filtered = computed(() => {
const kw = ui.keyword.toLowerCase()
return skills.filter(s => {
const byCat = ui.skillCategory === 'all' || s.category === ui.skillCategory
const byKw = !kw || s.name.toLowerCase().includes(kw)
return byCat && byKw
})
})
</script>
<!-- src/components/Projects.vue -->
<template>
<section id="projects" class="container section" aria-labelledby="projects-title">
<h2 id="projects-title">作品集 Projects</h2>
<div class="field" style="margin:12px 0;">
<label><input type="checkbox" v-model="onlyFeatured" /> 只看精選</label>
</div>
<p v-if="ps.loading">載入中...</p>
<p v-else-if="ps.error" class="error">載入失敗,請稍後再試。</p>
<div v-else class="project-grid">
<article class="card" v-for="p in view" :key="p.id">
<h3>{{ p.title }}</h3>
<p class="muted">{{ p.tech }}</p>
<p>{{ p.desc }}</p>
<div style="display:flex; gap:8px; margin-top:8px;">
<RouterLink class="btn small" :to="{ name:'project-detail', params:{ slug: p.slug } }">查看詳情</RouterLink>
<a class="btn small btn-outline" :href="p.repo" target="_blank" rel="noopener">GitHub</a>
</div>
</article>
</div>
</section>
</template>
<script setup lang="ts">
import { onMounted, ref, computed } from 'vue'
import { useProjectsStore } from '@/stores/projects'
const ps = useProjectsStore()
const onlyFeatured = ref(false)
const view = computed(() => {
const list = ps.items
return onlyFeatured.value ? list.filter(p => p.featured) : list
})
onMounted(() => { ps.fetchAll().catch(() => {}) })
</script>
<!-- src/views/ProjectDetail.vue -->
<template>
<section class="container section">
<nav style="margin-bottom:12px;">
<RouterLink to="/" class="btn btn-outline">← 返回列表</RouterLink>
</nav>
<p v-if="ps.loading">載入中...</p>
<p v-else-if="ps.error" class="error">讀取失敗,請返回列表。</p>
<template v-else>
<section v-if="project">
<h2>{{ project.title }}</h2>
<p class="muted">{{ project.tech }}</p>
<div class="gallery" v-if="project.images?.length" style="display:flex; gap:12px; flex-wrap:wrap; margin:12px 0;">
<img v-for="src in project.images" :key="src" :src="src" alt="專案截圖" width="360" />
</div>
<p>{{ project.desc }}</p>
<div style="display:flex; gap:8px; margin-top:8px;">
<a class="btn" :href="project.demo" target="_blank" rel="noopener">Live Demo</a>
<a class="btn btn-outline" :href="project.repo" target="_blank" rel="noopener">GitHub</a>
</div>
</section>
<section v-else>
<h2>找不到這個專案</h2>
<p class="muted">請回到列表,或確認網址是否正確。</p>
<RouterLink to="/" class="btn">返回列表</RouterLink>
</section>
</template>
</section>
</template>
<script setup lang="ts">
import { onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useProjectsStore } from '@/stores/projects'
const route = useRoute()
const ps = useProjectsStore()
const slug = computed(() => String(route.params.slug || ''))
const project = computed(() => ps.bySlug(slug.value))
onMounted(async () => {
if (!project.value && !ps.loading) {
try { await ps.fetchAll() } catch {}
}
})
</script>
如果想讓 Pinia 自動持久化,安裝插件 pinia-plugin-persistedstate
:
npm i pinia-plugin-persistedstate
// main.ts
import { createPinia } from 'pinia'
import piniaPersist from 'pinia-plugin-persistedstate'
const pinia = createPinia()
pinia.use(piniaPersist)
createApp(App).use(router).use(pinia).mount('#app')
// ui.ts(加入 persist)
export const useUiStore = defineStore('ui', {
/* ...state/getters/actions 同上... */
persist: {
key: 'ui',
paths: ['theme', 'skillCategory', 'keyword'] // 只存這些欄位
}
})
getter
裡改 DOM 或寫 localStorageaction
ref
存 keyword,同時 store 也存 → 產生不同步loading/error
,UI 顯示三態(loading / error / data)Vue 版收尾與最佳化 & 部署:
views
/ components
/ stores
/ router
/ styles
的專案結構<link rel="preload">
/ srcset
)